﻿#region Copyright 2011 Mike Goatly
// This source is subject to the the MIT License (MIT)
// All rights reserved.
#endregion

#if !NETFX_CORE
namespace Lifti.Persistence
{
    using System;
    using System.Linq;
    using System.Threading;
    using System.Transactions;

    using Lifti.Extensibility;
    using Lifti.Persistence.IO;

    /// <summary>
    /// A page manager implementation that is capable of partaking in transactions, ensuring
    /// the consistency of the underlying persisted file.
    /// </summary>
    /// <typeparam name="TItem">The type of the item contained in the index.</typeparam>
    public class TransactionalPageManager<TItem> : PageManager<TItem>, IEnlistmentNotification
    {
        /// <summary>
        /// The resource manager ID used to identify this resource manager with the transaction coordinator.
        /// </summary>
        private static readonly Guid ResourceManagerId = new Guid("7fcb64de-44b1-43f2-8ff2-dfaaf089aa3e");

        /// <summary>
        /// The synchronization object used to enforce that a transaction fully completes 
        /// before the page manager can be accessed again. Completions and rollbacks occur on another 
        /// thread and this lock will only be set once the transaction has been either committed or rolled back.
        /// </summary>
        private readonly ManualResetEvent transactionCompletionLock = new ManualResetEvent(true);

        /// <summary>
        /// The IO manager for the log file.
        /// </summary>
        private ITransactionLogFactory transactionLogFactory;

        /// <summary>
        /// The transaction log associated to the transaction on the current thread.
        /// </summary>
        private ITransactionLog transactionLog;

        /// <summary>
        /// Exception information raised during the commit or rollback methods.
        /// </summary>
        private Exception transactionException;

        /// <summary>
        /// The thread that a transaction action is occurring on.
        /// </summary>
        private Thread transactionActionThread;

        #region Constructors and Destructors

        /// <summary>
        /// Initializes a new instance of the <see cref="TransactionalPageManager&lt;TItem&gt;"/> class.
        /// </summary>
        /// <param name="pageCache">The page cache.</param>
        /// <param name="settings">The settings for this instance.</param>
        /// <param name="dataFileManager">The data file manager for this instance.</param>
        /// <param name="typePersistence">The type persistence implementation that will manage reading and writing
        /// type data to and from the persistence backing store.</param>
        /// <param name="transactionLogFactory">The transaction log factory.</param>
        /// <param name="extensibilityService">The extensibility service.</param>
        public TransactionalPageManager(IPageCache pageCache, IPersistenceSettings settings, IDataFileManager dataFileManager, ITypePersistence<TItem> typePersistence, ITransactionLogFactory transactionLogFactory, IIndexExtensibilityService<TItem> extensibilityService)
            : base(pageCache, settings, dataFileManager, typePersistence, extensibilityService)
        {
            this.transactionLogFactory = transactionLogFactory;
        }

        #endregion

        /// <summary>
        /// Gets the physical page numbers of the text index node data pages
        /// in the persisted store.
        /// </summary>
        /// <value>The physical page numbers of the text index data pages.</value>
        public override IDataPageCollection IndexNodeDataPages
        {
            get
            {
                this.EnsureValidTransactionState();
                return base.IndexNodeDataPages;
            }
        }

        /// <summary>
        /// Gets the physical page numbers of the item data pages
        /// in the persisted store.
        /// </summary>
        /// <value>The physical page numbers of the item data pages.</value>
        public override IDataPageCollection ItemDataPages
        {
            get
            {
                this.EnsureValidTransactionState();
                return base.ItemDataPages;
            }
        }

        /// <summary>
        /// Gets the total number of pages in the persisted file store. This
        /// includes unallocated pages.
        /// </summary>
        /// <value>
        /// The total number of physical pages in the store, both allocated and unallocated.
        /// </value>
        public override int TotalPageCount
        {
            get
            {
                this.EnsureValidTransactionState();
                return base.TotalPageCount;
            }
        }

        /// <summary>
        /// Gets the number of unused pages.
        /// </summary>
        /// <value>The unused page count.</value>
        public override int UnusedPageCount
        {
            get
            {
                this.EnsureValidTransactionState();
                return base.UnusedPageCount;
            }
        }

        /// <summary>
        /// Saves the changes in the given page. If the page has no remaining entries, it will be marked as unused and 
        /// returned to the unused page pool.
        /// </summary>
        /// <param name="page">The page to save.</param>
        public override void SavePage(IDataPage page)
        {
            this.EnsureValidTransactionState();
            base.SavePage(page);
        }

        /// <summary>
        /// Allocates a new internal index node id.
        /// </summary>
        /// <returns>
        /// The new id.
        /// </returns>
        public override int AllocateNewIndexNodeId()
        {
            this.EnsureValidTransactionState();
            return base.AllocateNewIndexNodeId();
        }

        /// <summary>
        /// Allocates the new internal item id.
        /// </summary>
        /// <returns>
        /// The new id.
        /// </returns>
        public override int AllocateNewItemId()
        {
            this.EnsureValidTransactionState();
            return base.AllocateNewItemId();
        }

        /// <summary>
        /// Initializes this instance.
        /// </summary>
        public override void Initialize()
        {
            // Make sure all initialization is done within a transaction.
            using (var scope = new TransactionScope())
            {
                base.Initialize();
                scope.Complete();
            }
        }

        /// <summary>
        /// Notifies this instance that a transaction is being prepared for commitment.
        /// </summary>
        /// <param name="preparingEnlistment">A <see cref="T:System.Transactions.PreparingEnlistment"/> object used to send a response to the transaction manager.</param>
        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "It's important to catch exceptions in this method as it will be executed on a different thread and any errors here may be terminal for the application")]
        public void Prepare(PreparingEnlistment preparingEnlistment)
        {
            if (preparingEnlistment == null)
            {
                throw new ArgumentNullException("preparingEnlistment");
            }

            this.transactionActionThread = Thread.CurrentThread;

            this.ExtensibilityService.OnTransactionPreparingForCommit();

#if DEBUG
            int itemPages = this.transactionLog.AffectedPages.OfType<ItemIndexDataPage<TItem>>().Count();
            System.Diagnostics.Debug.WriteLine(
                String.Format("Preparing - {0} item pages, {1} index pages, {2} item index pages",
                itemPages,
                this.transactionLog.AffectedPages.OfType<ItemNodeIndexDataPage>().Count(),
                this.transactionLog.AffectedPages.OfType<IndexNodeDataPage>().Count()));
#endif

            try
            {
                // First write to the log file the data that is about to be overwritten
                this.transactionLog.LogExistingDataForAffectedPages();

                // Now persist the actual changes to the IO manager.
                this.PersistAffectedDataToIOManager();

                preparingEnlistment.Prepared();

                this.ExtensibilityService.OnTransactionPreparedForCommit();
            }
            catch (Exception ex)
            {
                preparingEnlistment.ForceRollback();
                this.transactionException = ex;
            }
            finally
            {
                this.transactionActionThread = null;
            }
        }

        /// <summary>
        /// Notifies this instance that a transaction is being committed.
        /// </summary>
        /// <param name="enlistment">An <see cref="T:System.Transactions.Enlistment"/> object used to send a response to the transaction manager.</param>
        public void Commit(Enlistment enlistment)
        {
            if (enlistment == null)
            {
                throw new ArgumentNullException("enlistment");
            }

            System.Diagnostics.Debug.WriteLine("Committing");

            this.transactionActionThread = Thread.CurrentThread;
            this.ExtensibilityService.OnTransactionCommitting();

            try
            {
                this.transactionLog.MarkAsCommitted();
                this.transactionLog = null;

                enlistment.Done();
            }
            finally
            {
                this.transactionActionThread = null;

                this.ExtensibilityService.OnTransactionCommitted();

                System.Diagnostics.Debug.WriteLine("Committed");

                this.ReleaseTransactionLock();
            }
        }

        /// <summary>
        /// Notifies this instance that a transaction is being rolled back (aborted).
        /// </summary>
        /// <param name="enlistment">A <see cref="T:System.Transactions.Enlistment"/> object used to send a response to the transaction manager.</param>
        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "It's important to catch exceptions in this method as it will be executed on a different thread and any errors here may be terminal for the application")]
        public void Rollback(Enlistment enlistment)
        {
            if (enlistment == null)
            {
                throw new ArgumentNullException("enlistment");
            }

            System.Diagnostics.Debug.WriteLine("Rolling back");

            this.transactionActionThread = Thread.CurrentThread;
            this.ExtensibilityService.OnTransactionRollingBack(this.transactionException);

            try
            {
                var rollback = this.transactionLogFactory.CreateTransactionLogRollback(this);
                rollback.Rollback();

                var originalPageCount = this.transactionLog.OriginalPageCount;
                this.TotalPageCount = originalPageCount;

                // Clear the cache of any pages that were affected by the transaction - these will be reloaded when next accessed
                var affectedPages = this.transactionLog.AffectedPages.Select(p => p.Header).ToArray();
                this.PageCache.PurgePages(affectedPages.Select(p => p.PageNumber));

                // Rollback any affected page header data
                foreach (var pageHeader in affectedPages)
                {
                    // Don't bother reloading data for pages that were created during the transaction
                    if (pageHeader.PageNumber < originalPageCount)
                    {
                        var originalPageHeader = this.ReadPageHeader(pageHeader.PageNumber);
                        pageHeader.CurrentSize = originalPageHeader.CurrentSize;
                        pageHeader.EntryCount = originalPageHeader.EntryCount;
                        pageHeader.FirstEntry = originalPageHeader.FirstEntry;
                        pageHeader.LastEntry = originalPageHeader.LastEntry;
                        pageHeader.NextPage = originalPageHeader.NextPage;
                        pageHeader.PreviousPage = originalPageHeader.PreviousPage;
                        pageHeader.DataPageType = originalPageHeader.DataPageType;
                    }
                }

                // Purge any headers from the page cache that were created as part of a data file extension
                this.PageCache.PurgeHeaders(
                    this.transactionLog.CreatedPageNumbers
                    .Where(p => p >= originalPageCount));

                this.ReloadPageManagerHeader();

                this.transactionLog = null;
                enlistment.Done();
            }
            catch (Exception ex)
            {
                this.transactionException = ex;
            }
            finally
            {
                this.transactionActionThread = null;

                this.ReleaseTransactionLock();
            }

            this.ExtensibilityService.OnTransactionRolledBack(this.transactionException);
        }

        /// <summary>
        /// Notifies this instance that the status of a transaction is in doubt.
        /// </summary>
        /// <param name="enlistment">An <see cref="T:System.Transactions.Enlistment"/> object used to send a response to the transaction manager.</param>
        public void InDoubt(Enlistment enlistment)
        {
            if (enlistment == null)
            {
                throw new ArgumentNullException("enlistment");
            }

            enlistment.Done();

            this.ReleaseTransactionLock();
        }

        /// <summary>
        /// Gets the data page associated to the given page header.
        /// </summary>
        /// <param name="pageHeader">The header of the page to get.</param>
        /// <returns>The loaded data page.</returns>
        /// <remarks>To ensure that the correct page is returned during a transaction,
        /// the <see cref="TransactionLog"/> keeps track of pages that have been written.
        /// The first place <see cref="TransactionalPageManager&lt;TItem&gt;.GetPage(IDataPageHeader)"/> checks for the page is there.
        /// If that does not return the page, then it defers to the base <see cref="PageManager&lt;TItem&gt;.GetPage(IDataPageHeader)"/>.</remarks>
        public override IDataPage GetPage(IDataPageHeader pageHeader)
        {
            if (pageHeader == null)
            {
                throw new ArgumentNullException("pageHeader");
            }

            this.EnsureValidTransactionState();

            if (this.transactionLog != null)
            {
                var transactionPage = this.transactionLog.TryGetPage(pageHeader.PageNumber);
                if (transactionPage != null)
                {
#if DEBUG
                        if (transactionPage is ItemIndexDataPage<TItem>)
                        {
                            System.Diagnostics.Debug.WriteLine("Page " + transactionPage.Header.PageNumber + " read from transaction cache");
                        }
#endif

                    return transactionPage;
                }
            }

            return base.GetPage(pageHeader);
        }

        /// <summary>
        /// Gets the data page held at the given page number.
        /// </summary>
        /// <param name="pageNumber">
        /// The physical page number of the data page to read.
        /// </param>
        /// <returns>
        /// The loaded data page.
        /// </returns>
        /// <remarks>To ensure that the correct page is returned during a transaction,
        /// the <see cref="TransactionLog"/> keeps track of pages that have been written.
        /// The first place <see cref="TransactionalPageManager&lt;TItem&gt;.GetPage(IDataPageHeader)"/> checks for the page is there.
        /// If that does not return the page, then it defers to the base <see cref="PageManager&lt;TItem&gt;.GetPage(IDataPageHeader)"/>.</remarks>
        public override IDataPage GetPage(int pageNumber)
        {
            this.EnsureValidTransactionState();

            if (this.transactionLog != null)
            {
                var transactionPage = this.transactionLog.TryGetPage(pageNumber);
                if (transactionPage != null)
                {
                    return transactionPage;
                }
            }

            return base.GetPage(pageNumber);
        }

        /// <summary>
        /// Creates a new page after the given data page. All required page references
        /// will be updated. Affected page headers will be persisted to the backing file store.
        /// </summary>
        /// <typeparam name="TDataPage">The type of the data page.</typeparam>
        /// <param name="previousPage">The page to create the new page after.</param>
        /// <returns>
        /// The new page that follows on directly from the given page.
        /// </returns>
        public override TDataPage CreatePage<TDataPage>(TDataPage previousPage)
        {
            this.EnsureValidTransactionState();

            TDataPage newPage = default(TDataPage);
            this.PerformTransactionAction(l =>
                {
                    newPage = base.CreatePage<TDataPage>(previousPage);
                    l.RegisterAffectedPage(newPage, PageWriteLevels.Created);
                });

            return newPage;
        }

        /// <summary>
        /// Logs the fact that the given page has been written during a transaction. If no transaction
        /// is currently open then an implicit transaction is created and the page is persisted within that. 
        /// </summary>
        /// <param name="page">The page to persist.</param>
        protected override void PersistPage(IDataPage page)
        {
            this.PerformTransactionAction(l => l.RegisterAffectedPage(page, PageWriteLevels.Body));
        }

        /// <summary>
        /// Logs the fact that the given page's header has been written during a transaction. If no transaction
        /// is currently open then an implicit transaction is created and the page header is persisted within that. 
        /// </summary>
        /// <param name="page">The page to persist the header for.</param>
        protected override void PersistPageHeader(IDataPage page)
        {
            this.PerformTransactionAction(l => l.RegisterAffectedPage(page, PageWriteLevels.Header));
        }

        /// <summary>
        /// Logs the fact that the page manager header has been written during a transaction. If no transaction
        /// is currently open then an implicit transaction is created and the page manager header is persisted within that.
        /// </summary>
        protected override void PersistPageManagerHeader()
        {
            this.PerformTransactionAction(l => l.PageManagerHeaderWritten = true);
        }

        /// <summary>
        /// Releases unmanaged and - optionally - managed resources
        /// </summary>
        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
        protected override void Dispose(bool disposing)
        {
            try
            {
                // Important to wait for a valid transaction state - we don't want to dispose the
                // log file until any outstanding transaction logs have been written
                this.EnsureValidTransactionState();

                if (disposing)
                {
                    if (this.transactionLogFactory != null)
                    {
                        this.transactionLogFactory.Dispose();
                        this.transactionLogFactory = null;
                    }
                }
            }
            finally
            {
                base.Dispose(disposing);
            }
        }

        /// <summary>
        /// Ensures that if the system is not currently in a transaction, then the previous
        /// transaction has been fully completed, i.e. committed or rolled-back.
        /// </summary>
        private void EnsureValidTransactionState()
        {
            var currentLog = this.transactionLog;
            var currentTransaction = Transaction.Current;

            // It's possible that a new transaction has started up before the Prepare or Commit code has started.
            // If this is the case then the new transaction should wait for the previous to commit or rollback.
            bool transactionChanged = currentTransaction != null && currentLog != null && currentLog.TransactionId != currentTransaction.TransactionInformation.LocalIdentifier;

            // We're allowed to be not be in a transaction if we're currently operating on the rollback thread
            if ((transactionChanged || currentLog == null || currentTransaction == null)
                && Thread.CurrentThread != this.transactionActionThread)
            {
                // There is no outstanding transaction. Wait on the transaction lock - this will
                // either be already cleared because the transaction has committed, or it *may*
                // be still set if it is being rolled back.
                if (!this.transactionCompletionLock.WaitOne(3000))
                {
                    throw new PersistenceException("Lock timeout waiting for previous transaction to complete");
                }
            }
        }

        /// <summary>
        /// Performs an action within a transaction, creating an implicit transaction if required.
        /// </summary>
        /// <param name="logAction">The action to perform on the <see cref="TransactionLog"/>.</param>
        private void PerformTransactionAction(Action<ITransactionLog> logAction)
        {
            if (Transaction.Current == null)
            {
                throw new PersistenceException("No ambient transaction - the full text index class must be responsible for creating an ambient transaction");
            }

            if (this.transactionLog == null)
            {
                this.transactionException = null;
                this.transactionLog = this.transactionLogFactory.CreateTransactionLog(Transaction.Current.TransactionInformation.LocalIdentifier, this);

                System.Diagnostics.Debug.WriteLine("Enlisting transaction");

                Transaction.Current.EnlistDurable(ResourceManagerId, this, EnlistmentOptions.None);

                // Reset the transaction lock, allowing us to block later on until the thread is committed/rolled-back
                this.transactionCompletionLock.Reset();

                this.ExtensibilityService.OnTransactionEnlisted();
            }

            logAction(this.transactionLog);
        }

        /// <summary>
        /// Persists the data affected during the transaction to the underlying IO manager.
        /// </summary>
        private void PersistAffectedDataToIOManager()
        {
            if (this.transactionLog.PageManagerHeaderWritten)
            {
                base.PersistPageManagerHeader();
            }

            foreach (var page in this.transactionLog.AffectedPageHeaders)
            {
                base.PersistPageHeader(page);
            }

            foreach (var page in this.transactionLog.AffectedPageBodies)
            {
                base.PersistPage(page);
            }
        }

        /// <summary>
        /// Releases the transaction lock.
        /// </summary>
        private void ReleaseTransactionLock()
        {
            this.transactionCompletionLock.Set();
            System.Diagnostics.Debug.WriteLine("Lock released");
        }
    }
}
#endif